Kotlin/Golang работа в двух языках

Сразу дисклеймер, статья больше про Golang, но мой «родной» и основной на протяжении уже 6 лет — Kotlin — буду рад если будут замечания по Golang части в комментариях

Немного о себе — системный архитектор компании SpectrumData, тут вроде как по канонам хабра ни рекламы ничего давать нельзя, но есть канал по программированию у нас — можете найти — может что есть интересного. В архитекторах я оказался из разработчиков, стаж более 20 лет на разных платформах и задачах. Сейчас тоже стараюсь и сам писать и есть команды разработчиков в подчинении.

Никогда не писал на хабре. Обычно если какой-то материал появлялся, то для внутренних нужд или лучше смотрится в ролике. Но тут материала подкопилось текстового. Решил написать статью. Может кому-то будет интересно.

Так уж получилось, что у нас в компании используются разные стеки и языки. И в частности у нас есть большое подразделение, основным стеком которого является JVM с Kotlin в качестве языка разработки (вместо ванильной Java, на бэкенде). Но при этом этому же отделу регулярно приходится использовать в работе GoLang. В частности бывают кейсы:

  • портирования кода (в обе стороны)

  • реализации каких-то компонентов сразу на 2-х языках (в основном это внутренние SDK)

Сразу скажу — почему эта задача вообще в целом для нас легкая и подъемная — наши бэкенды на Kotlin строятся на микрофреймворках типа Ktor, а не на Spring или не дай бог JavaEE, соответственно тяжелых вопросов соответствия каких-то лютых Enterprise монструозных JAR каким-то решениям в Golang не стоит.

Естественно, что мы сейчас говорим про языки и соответствие КОНСТРУКЦИЙ , а не про библиотеки или фреймворки.

Ну и некоторых ставит поначалу в ступор, что Kotlin/JVM это «про классы» и «не натив», а Golang это вроде как «процедурный стиль» и «натив».

На деле практически все довольно органично воспроизводится. В этой статье приведу некоторые примеры взаимозаменяемых конструкций и хаков. Материал в основном для тех кто пишет на Kotlin и для кого Golang — второй язык.

Сразу оговорюсь, что при переносе можно выделить несколько ситуаций, под одну гребенку все невозможно завести.

Простые:

  1. перенос один в один или очень близко в той же структуре кода (без учета непосредственно синтаксиса языка) с сохранением канона (каноничного стиля кода GoLang) — да почти все

  2. перенос один в один или через хаки, но без сохранения канона — код и API в итоге очень похоже на Kotlin, но при этом код не каноничен для GoLang — например статические методы, синглтоны, компаньоны

  3. нельзя перенести один в один, но есть канонические легко осваиваемые паттерны которые «по духу» и смыслу аналогичны Kotlin — как ни странно — почти все ООП воспроизводится без особых потерь на структурах без классов

Тяжелые:

  1. требуют пересмотра парадигмы языка и переноса не в лоб, требуют хорошего понимания концепций обоих языков — например соответствие пакетов в JVM и распределения кода и пакетов в Golang, любые переносы решений с большим использование корутин (они обманчиво похожи на горутины, но требуют иного планирования)

  2. можно перенести только хаками, при этом код на выходе плохой, не каноничный и при этом не до конца полный по смыслу и духу — это попытки полностью реализовать перечисления как классы, тотальную иммутабельность и защиту от NPE, решения сильно завязанные на рефлексию и т.п.

  3. вообще нельзя сделать идентичным — не так много таких вещей, но по факту это все, что сильно завязано на генериках в JVM понимании и сахарная функциональщина типа DSL

List<T>.filter vs List<T>.map

Начнем с примера в котором сразу будут показаны некоторые типовые переносы и один невозможный перенос.

Итак мы хотим перенести в Go функциональную обработку коллекций (а там этого явно не хватает, понятно есть какие-то внешние пакеты, но допустим хотим свое)

fun <T> List<T>.filter(condition : (T)->Boolean): List<T> {     return buildList {       for (item in this) {         if (condition(item)){           add(item)         }       }     } } fun <T,R> List<T>.map(mapper : (T)->R): List<R> {     return buildList {       for (item in this) {           add(mapper(item))       }     } } 

И вот мы начинаем воспроизводить

Во-первых мы хотим это исполнить именно как метод, а не как функцию, чтобы их делать в цепь l.Filter().Filter().Map().First(), а не вкладывать First( Map( Filter ( Filter(l)))

Пробуем решить в лоб (не получится)

// пробуем навесить метод прямо на срез func (s []any) Filter(condition func(item any) bool) []any 

Сразу куча проблем — во-первых так нельзя — навешивать функции на чужие типы, во-вторых у нас резко теряется информация о типе!

// пробуем сделать generic-метод func (s []T) Filter[T any](condition func(item T) bool) []any

а так тем более нельзя — потому что вообще нет GENERIC методов в Golang, не завезли, функции есть, а методов — нет!

Но тут на помощь приходит то, что по своей природе Golang — это в своей основе C, где нет аьясов типа, а есть создание типа на основе данного. Вот так можно:

type List[T any] []T func (l List[T]) Filter(condition func(item T) bool) List[T]

Итак — первое, что уже можно выучить — нельзя навесить «расширение» на уже кем-то в другом пакете написанную структуру, но можно сделать тип в своем пакете, эквивалентный целевому и сделать метод уже у него!

но так просто это использовать не получится, потребуется:

// так не получится ([]int{1,2,3}).Filter(func(item int) bool {return item > 1}) // а вот так да: List[int]([]int{1,2,3}).Filter(func(item int) bool {return item > 1})

не красивый повтор параметра типа…, немного усовершенствуем:

// сделали а-ля приватную структуру, которую снаружи в явном виде создать нелья // но в отличие от Kotlin можно ВОЗВРАЩАТЬ type _ListType[T any] []T // навесили на нее наш метод func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] {...} // сделали "конструктор" func List[T](l []T) _ListType[T] { return _ListType[T](l) }  // теперь сработает автовывод типа mylist := List([]int{1,2,3}).Filter(...) // _ListType[int]

Заодно приведем вариант реализации этого Filter, вдруг она кому-то не очевидна

func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] {     var result []T     for _, item := range l { // _ListType[T] все еще []T         if condition(item) {             result = append(result, item)         }     }     return result  // автоматический апкаст до _ListType[T] автоматически }

Окрыленные своим успехом, мы без проблем реализуем такие методы как Take, TakeLast, Drop, DropLast , First, FirstOrDefault…

Кстати а как сделать FirstOrDefault()?

И тут как это ни странно в Java/Kotlin, при всем богатстве рефлексии — это сложно, так как не очень понятно как именно в общем случае (не в частном, а общем) получить дефолтный экземпляр некоего типа T !!! Вот, что примерно бы было в Kotlin:

fun <T: Any> List<T>.firstOrDefault(): T {     if (this.size > 0) return this[0] // тут все просто, а вот дальше...     // все, приплыли } // немного переделаем fun <T: Any> List<T>.firstOrDefault(clazz : KClass<T> ): T {     if (this.size > 0) return this[0] // тут все просто, а вот дальше...     return clazz.createInstance()      // ну и мы понимаем, что это ни разу не общее решение и с кучей типов      // это не сработает как надо !!! } // добавим сахара inline fun <reified T:Any> List<T>.firstOrDefault(): T =      this.firstOrDefault(T::class)

В Golang это решается проще и можно запомнить идиому:

func (l _ListType[T]) FirstOrDefault() T {     if len(l) > 0 { return l[0] }     var def T // просто определяем переменную!      // и так как в GO все переменные инициализируются дефолтным значением,     // например 0, "", nil, пустая структура - то все, вуаля - можно возвращать     return def }

Зато сложнее получить в общем случае поведение DefaultOrNil(), которое в Kotlin несколько проще достигается… ну это уже совсем нюансы

Итак — второй «хак» — в Golang легко получить дефолт любого типа ,

просто определив переменную этого типа

И вот мы очень все еще окрылены нашим успехом переноса функциональщины, частично генериков и расширений и все идет как надо….

Более того все переносы они даже и канонов каких-то особых не нарушают и читаются легко.

Но тут мы резко и без предупреждения споткнемся о такой простой метод как List.map, напомню его код:

fun <T,R> List<T>.map(mapper : (T)->R): List<R> {     return buildList {       for (item in this) {           add(mapper(item))       }     } }

Пытаемся в лоб:

func (l _ListType[T]) Map[R any] (mapper func(src T) R) _ListType[R] {     var result []R     for _, item := range l {       result = append(result, mapper(item))     }     return result }

И тут мы упремся в короткое и лаконичное сообщение компилятора Golang:

syntax error: method must have no type parameters

О как! Обычные функции могут иметь тип-параметры , а методы (у которых есть ресивер) — нет! И более того нет никаких признаков, что их в ближайшее время завезут(!!!).

И вот тут мы напарываемся на первую преграду действительно серьезную:

Шаблоны (генерики) в Golang намного слабее и не идут ни в какое сравнение по мощности и выразительности ни с Java/Kotlin ни тем более с Rust или с теми же шаблонами C++. Если ваше решение сильно завязано на генерики и они есть как у классов, так и у методов или расширений — скорее всего это та грань и та черта проекта, которая будет практически невозможно перенести на Golang без потерь в эргономике или семантике!!!

И получается, что в рамках нашей задумки вполне можно реализовать методы, которые не требуют второго генерика и не получится нормально тех, которые требуют (Map, Zip, частично Fold, Reduce).

Соответственно мы можем реализовать Map , Fold, Reduce только в варианте с тем же типом, но не в обобщенной форме, то есть на вход List<T> и на выход List<T> или T, но не List<R>, R:

func (l _ListType[T]) Map (mapper func(item T) T) _ListType[T] {     var result []T     for _, item := range l {         result = append(result, mapper(item))     }     return result }

В таком виде естественно будет работать — но очевидно что это не тот Map о котором мы джва года уже мечтали…

Соответственно какие выводы можно сделать:

  • в целом нет